목차
[수정 사항]
2023-03-29 : 커스텀 어노테이션 적용하는 부분에서 소스코드에 잘못 들어간 코드가 있어서 삭제
2024-01-12 : SpringSecurityConfig 클래스의 코드에 어노테이션 빠져 있던 부분 추가
1. 시작하기 전에1.1 설정 이해 보다는 당장 시큐리티 설정 복붙이 필요한 분들에게
이하 글에서는 import 를 제외하고 작성했고, 코드들이 글에 작성되어 있다보니 완전 초보라면 이해하기 힘들 것 같습니다. 또는 당장 급하게 복붙할 스프링 시큐리티 기본 세팅이 된 프로젝트를 찾는 경우도 있을겁니다. 그러니 우선 예시 프로젝트를 첨부합니다. 이 글은 이하의 예시 프로젝트를 만들기 위해 세팅하는 과정을 다룹니다.
- 프로젝트 (스프링부트 3.0.2 기준) : github
(main 브랜치를 보시면 됩니다. base-without-spring-security 브랜치는 이 글을 따라하면서 설정을 이해할 분들이 받아서 실습 해보실 수 있게 스프링 시큐리티 설정을 제거한 프로젝트 입니다.)
1.2 스프링부트 3.0에서 시큐리티 설정 방법이 꽤 바뀌었어요.
스프링부트 3.0 부터 스프링 시큐리티 6.0.0 이상의 버전이 적용되었습니다. 2.7.3이후로 시큐리티 설정쪽에 deprecated 되는게 추가되더니 3.0부터는 아예 삭제된 설정방식들이 있습니다. 이런 부분이 아직 스프링 부트 reference 문서에도 반영이 되어 있지 않아 스프링 블로그나 깃헙등을 통해 알아내야 하는 부분들이 있었습니다(물론 고수분들은 함수 까봐서 가능했을 것 같아요.). 그런 부분들을 공유하고자 글을 작성했습니다.
1.3 이 글의 목표
최종적으로 기본적인 회원가입, 로그인, 역할에 따른 API 사용 제한을 걸 수 있는 스프링 시큐리티가 적용된 기본적인 형태의 스프링 부트 3.0 프로젝트를 만드는게 이 글의 목표입니다. 참고로 전 스프링 시큐리티를 그냥 레퍼런스나 블로그들 보면서 배웠기 때문에, 책이나 강의를 보고 딥하게 이해하고 작성한 글이 아님을 미리 얘기드립니다.
1.4 스프링 부트 2.7.8 이하에 스프링 시큐리티를 적용하려는 분들에게
이 글의 내용은 스프링부트 3.0.2를 기준으로 작성되었습니다. 스프링부트 3.0.0, 3.0.1로도 동일하게 적용 가능하고, 당분간은 3.0대에서는 문제없이 적용될 것 같습니다. 다만 스프링부트 2.7.8 이하로는 이 글대로 따라하실 수 없을 수 있습니다. 스프링부트 2.7.8 이하에서 스프링 시큐리티 기본 세팅을 하시려는 분은 '스프링부트 Spring Security 기본 세팅 - 2.7.8 이하' 글을 참고해주세요.
1.5 스프링 부트 2.7.8 이하에서 3.0으로 스프링 시큐리티 설정 마이그레이션하려는 분들에게
2.7.8 이하에서 적용되던 스프링 시큐리티과의 변경점에 대해서는 이 글에서 다루지 않습니다. 기존 스프링 시큐리티를 마이그레이션 하기 위해 변경점만 파악하고 싶다면 '스프링 부트 2.0에서 3.0 스프링 시큐리티 마이그레이션 (변경점)' 글을 참고해주세요.
2. 스프링 시큐리티가 적용되지 않은 베이스 프로젝트 (따라 해보기용)2.1 베이스 프로젝트 (시큐리티 설정이 없어요)github
- base-without-spring-security 브랜치 : 이 글을 보면서 따라해보며 설정 방법을 이해해보시려는 분들은 이 브랜치에서 시작하시면 됩니다.
- main 브랜치 : 최종적으로 main 브랜치 형태로 스프링 시큐리티를 적용할껍니다. 즉, 이 글은 base-without-spring-security 브랜치에서 시작해 main 브랜치로 만들어가는 과정입니다.
- 베이스 프로젝트까지 구현하는건 스프링 시큐리티 설정 얘기와 관련이 없는 다른 부분이므로 별도로 다루지 않습니다. 베이스 프로젝트 세팅 시 염두한 점은 이하와 같습니다.
DB는 H2를 사용했습니다. 시큐리티 글에서 별도로 DB 설치까지 얘기하긴 이상하니 코드 자체로 별다른 DB 설치 없이 실행 가능해서 선택했습니다. 메모리 DB라 매번 실행 시 마다 데이터는 삭제됩니다. schema.sql에 기본적인 테이블 생성 쿼리가 있고, data.sql에서 기본 데이터를 넣어줄 수 있습니다.DB 연결은 data jpa를 사용했습니다. 사실 초보자라면 Mybatis를 사용할 것 같긴한데, 시큐리티 설정에 주 목적인 글이다보니 간편하게 data jpa로 구성했습니다. 그리고 이 글을 읽는분이 Raw JPA를 사용할수도 있고, Mybatis를 사용할 수도 있는데 이 때 가장 걷어내고 자기 코드를 추가하기 쉽다고 생각됩니다.간단한 예제이고, 이클립스 사용하시는분은 바로 실행이 되지 않을 것 같아 롬복은 쓰지 않았습니다. (인텔리제이는 바로 실행 가능한데 이클립스를 롬복을 별도로 세팅해야됨)요즘 React 등으로 많이 넘어가긴 했지만, 처음 스프링부트에 입문한 분들은 대부분 JSP로 시작할 거라 생각됩니다. 보통 JSP 이후 타임리프는 건너띄고 React로 넘어갈 것 같네요. 그래서 웹페이지는 JSP로 구성했습니다. 다만 전 프론트쪽이 약한편이라 좀 지저분하거나 비효율적일 수 있습니다. 타임리프로 구성한다면 build.gradle에서 타임리프 의존성을 넣고 templates 폴더를 사용해주면 됩니다.패키지 구성은 layered architecture 형태로 작성했습니다. 일반적으로 많이 쓰는 아키텍처라 익숙할거라 생각했습니다.
2.2 스프링 시큐리티 적용 전 베이스 프로젝트 살펴보기
A. 로그인
로그인이 되긴합니다! 근데 로그인 안해도 주소만 알면 다른 페이지에 접근이 되긴해요.
B. 회원가입
회원가입도 잘 됩니다! 근데 비밀번호가 암호화되어 들어가있진 않아요.
C. 대시보드
사실 로그인을 안해도 여기 들어올순 있어요! 하지만 접속 아이디는 안뜰꺼에요. 접속 아이디는 어딛냐면 웹의 sessionStorage에 있습니다! 그래도 hidden처리된 input에 넣어두진 않았네요.
D. 역할에 따른 페이지 이동 권한 확인
로그인해도 백엔드에서 접근 제어를 안해뒀네요. 관리자 설정 페이지도 그냥 들어가져요. 매번 sessionStorage에 들어있던 접속 아이디를 백에 보내서 막을 순 있을 것 같지만 다른 세팅도 안했는거 굳이 이것만 하긴 이상해요!
2.3 스프링 시큐리티 적용 후 최종적인 프로젝트 구성 미리보기
이후 위 화면들의 문제점을 해결하기 위해 스프링 시큐리티를 적용할겁니다. 최종적으로 완료된 프로젝트 형태는 다음과 같습니다.
3. 로그인 안했으면 로그인 화면으로 보내줘!
현재 프로젝트는 로그인을 하지 않아도 원하는 페이지의 url만 알면 전부 들어갈 수 있는 상태입니다. 로그인창이나 회원가입창은 로그인을 안해도 들어갈 수 있어야 하는게 맞지만, 그 외에는 전부 로그인 후 접근이 가능해야 합니다. 베이스 프로젝트에 스프링 시큐리티를 적용해서 막아보겠습니다.
3.1 프로젝트에 스프링 시큐리티 적용디펜던시를 추가해줍니다. 버전은 스프링 부트 개발자들이 정해두었으므로 따로 설정하지 않는게 좋습니다.
gradle
implementation 'org.springframework.boot:spring-boot-starter-security'
maven
org.springframework.bootspring-boot-starter-security
스프링 시큐리티를 위해 Configuration을 추가해줍니다. config 패키지를 만들고 SpringSecurityConfig 클래스를 다음과 같이 추가했습니다. 설정에 대한 간단한 설명은 주석으로 달아두었습니다.@Configuration@EnableWebSecurity@EnableMethodSecuritypublic class SpringSecurityConfig {@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.csrf().disable().cors().disable().authorizeHttpRequests(request -> request.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll().anyRequest().authenticated()// 어떠한 요청이라도 인증필요).formLogin(login -> login// form 방식 로그인 사용.defaultSuccessUrl("/view/dashboard", true)// 성공 시 dashboard로.permitAll()// 대시보드 이동이 막히면 안되므로 얘는 허용).logout(withDefaults());// 로그아웃은 기본설정으로 (/logout으로 인증해제)return http.build();}}
csrf().disable().cors().disable() 부분은 csrf와 cors를 disable 하는건데, 이 부분이 필요하다면 별도로 찾아서 설정해주시면 됩니다. 일반적으로 이 프로젝트처럼 웹페이지가 있는 경우엔 csrf는 disable하지 않긴 합니다(설정안하면 default가 on). cors 설정은 우선 cors가 뭔지도 적어야 할 것 같아 이 글에선 뺐습니다.
dispatcherTypeMatchers 부분의 설정은 스프링 부트 3.0부터 적용된 스프링 시큐리티 6.0 부터 forward 방식 페이지 이동에도 default로 인증이 걸리도록 변경되어서 JSP나 타임리프 등 컨트롤러에서 화면 파일명을 리턴해 ViewResolver가 작동해 페이지 이동을 하는 경우 위처럼 설정을 추가하셔야 합니다.
이제 서버를 시작해보면 어디로 접근하던지 아래와 같은 페이지가 뜨는걸 볼 수 있습니다.
스프링 부트에서 기본적으로 제공해주는 로그인 페이지 입니다. 아직 추가 설정을 하지 않았으므로 username에는 user, password에는 서버 실행 시 콘솔에 뜨는 임시 비밀번호를 적어주시면 됩니다.
그럼 아직 아무것도 안뜨고 설정 페이지도 모두 접근이 가능하긴 하지만 사용자가 임의로 접근하더라도 login 페이지로 자동으로 이동되며, 로그인 후 저희가 원하는 페이지(/view/dashboard)로 이동까지는 가능하게 된 상태 입니다.
3.2 만들어둔 로그인 페이지로 보내기현재는 스프링 부트에서 제공하는 기본 로그인 페이지를 사용중인데, 실제로 저 페이지를 쓸 일은 없습니다. 커스텀으로 만든 페이지로 이동시켜야 합니다.@Configuration@EnableWebSecurity@EnableMethodSecuritypublic class SpringSecurityConfig {@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.csrf().disable().cors().disable().authorizeHttpRequests(request -> request.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll().anyRequest().authenticated()).formLogin(login -> login.loginPage("/view/login")// [A] 커스텀 로그인 페이지 지정.loginProcessingUrl("/login-process")// [B] submit 받을 url.usernameParameter("userid")// [C] submit할 아이디.passwordParameter("pw")// [D] submit할 비밀번호.defaultSuccessUrl("/view/dashboard", true).permitAll()).logout(withDefaults());return http.build();}}
[A] 커스텀 로그인 페이지 지정
현재 아래와 같이 커스텀 로그인 페이지를 지정해두었습니다. 따라서 loginPage("/view/login") 으로 지정해주었습니다.
@Controller@RequestMapping("/view")public class ViewController {@GetMapping("/login")public String loginPage() {return "login";}...}[B] submit 받을 url 지정
지정하지 않을 시 기본은 'POST /login' 입니다. 커스텀 하는 방법을 보여드리려고 일부러 지정했습니다. 또한 기존에 '/login-process'는 이제 쓸모가 없으니 지워줍니다. 이후로는 스프링에서 인증을 관리할겁니다.
//LoginController의 이하 부분 삭제! 필요없어짐.@PostMapping("/login-process")public String login(MemberLoginDto dto) {boolean isValidMember = memberService.isValidMember(dto.getUserid(), dto.getPw());if (isValidMember)return "dashboard";return "login";}[C], [D] submit할 아이디와 비밀번호
이건 바로 아래쪽에서 프론트에 작성한 코드로 보시는게 더 이해하기 좋을 것 같습니다. 설정하지 않을 시 각각 username과 password로 name을 설정하시면 됩니다. 역시 커스텀하는 방법을 보여드리려고 일부러 넣었습니다.
커스텀 로그인 페이지에 위에 설정한걸 살펴보자. (login.jsp 파일)
form에 method는 post로 해주셔야 합니다. action은 설정에서 적용한 [B] 부분을 적으시면 되고, 설정 안하셨다면 /login 으로 하시면 됩니다. [C]와 [D]도 마찬가지로 name에 적어주시면 되고, 설정안했다면 username과 password로 기입하면 됩니다.
jsp 파일 온김에 login.jsp 맨 아래쪽에 있던 script랑 dashboard 아래쪽의 script를 제거해주십시다! 우린 이제 시큐리티를 쓸꺼니 프론트쪽의 storage에 담긴 정보는 아예 사용하지 않을꺼에요.
이제 서버를 실행해보면.. 짜잔!
아무튼 기본 로그인 페이지는 아니네요. 로그인 자체는 되긴 합니다. 아직은 DB에 들어있는걸로는 안되고, 아이디는 user, 비밀번호는 마찬가지로 콘솔창에 떠있는 임시 비밀번호를 써야 합니다.
근데 뭔가 이상합니다! 이미지도 안뜨고, 회원가입도 안눌립니다. 회원 가입도 안되는데 로그인하래요! 어딜 접근해도 로그인페이지로 되돌아옵니다. 뭐.. 아무튼 막았죠?
4. 아니 암만 그래도 회원가입은 로그인안해도 들어가져야지!4.1 로그인 안해도 호출 가능해야하는 API 예외 처리 하기
로그인 안해도 들어가질 화면은 들어가져야 합니다. 구글에서 메일이야 로그인하고 들어가야하지만, 검색까지 로그인하고 검색하게 막아뒀으면 구글 안썼을 것 같습니다! 그리고 로그인 화면에 이미지는 또 왜 안뜬걸까요? 당연히 떠야할 것 같이 생겼습니다.
로그인하지 않아도 들어갈 수 있거나 보여야할 예외들을 처리해봅시다.
@Configuration@EnableWebSecurity@EnableMethodSecuritypublic class SpringSecurityConfig {@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.csrf().disable().cors().disable().authorizeHttpRequests(request -> request.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll().requestMatchers("/status", "/images/**", "/view/join", "/auth/join").permitAll().anyRequest().authenticated()).formLogin(login -> login.loginPage("/view/login").loginProcessingUrl("/login-process").usernameParameter("userid").passwordParameter("pw").defaultSuccessUrl("/view/dashboard", true).permitAll()).logout(withDefaults());return http.build();}}requestMatchers("/status", "/images/**", "/view/join", "/auth/join").permitAll()
처음엔 anyRequest() 어떠한 요청이라도 authenticated() 인증을 적용하겠다고 했는데, 만들어보니 예외처리할 부분이 생겼습니다.
- "/images/**"은 static 폴더에 있는 이미지 파일입니다! 로그인 화면에 이미지 하나 정도는 있어야 이쁠 것 같은데 그것조차 인증에 막혀있었으니 인증 안되도 통과되게 해줍시다.
- "/view/join"과 "/auth/join"은 각각 회원가입 화면과 회원가입 API 입니다. 둘다 풀어줍시다.
- "/status"는 그냥 상태확인용으로 호출이 성공만하면 200 ok를 리턴해주는 간단한 API 입니다. 일반적으로 클라우드에 로드밸런서를 구성할 시 상태확인용 API를 제공해야 해당 인스턴스가 정상인지 판단 가능합니다. 또한 별도로 확인하는 로직을 구성하고 싶을 때도 이런 API가 필요합니다. 이 글에서 필수는 아니지만 예시로 넣어두었습니다.
이제 실행해보면 로그인 페이지에 이미지가 잘 뜹니다! 로그인은 여전히 콘솔에 뜨는 임시 비밀번호로 해야합니다.
(참고로 저 곰 이미지는 제가 그림판으로 그린겁니다.)
회원가입도 잘 들어가집니다!
아직 로그인할 때 DB의 정보를 기준으로 로그인되지 않으므로 회원가입 자체는 동작하더라도 로그인에 사용할 순 없습니다.
/status 도 잘 됩니다!
그냥 들어가서 확인해보셔도 되고
간단히 /status를 호출하는 테스트 코드를 짜두었으니 그걸 실행해보셔도 됩니다.
참고로 로그아웃도 잘 됩니다.
'POST /logout' 을 호출하도록 하면 인증이 풀리면서 로그인 페이지로 이동됩니다. 설정에서 기본 설정으로 지정했기 때문입니다. 별도로 원하는 logout url이 있다면 login때와 마찬가지로 설정하시면 됩니다.
5. 임시 비밀번호 말고 DB에 있는 데이터로 로그인할래요!5.1 DB에서 가져온 유저 정보를 시큐리티한테 넘겨주기
현재까지는 콘솔창에 뜬 스프링부트가 제공해준 임시 비밀번호를 사용해 로그인 했습니다. 하지만 우린 이미 만들어둔 회원 DB가 있습니다. 그러니 거기서 가져온 정보를 스프링부트에게 제공해서 DB를 기준으로 로그인될 수 있도록 해줘야 합니다. 스프링 시큐리티 설정 자체에서도 가능하지만, 이 글에서는 UserDetailsService를 상속받아 구현해보겠습니다.
config 패키지에 UserDetailsService를 상속받아 DB에서 회원정보를 받아와서 스프링부트에게 넘겨주는 부분을 구현합니다.
@Componentpublic class MyUserDetailsService implements UserDetailsService {private final MemberService memberService;public MyUserDetailsService(MemberService memberService) {this.memberService = memberService;}@Overridepublic UserDetails loadUserByUsername(String insertedUserId) throws UsernameNotFoundException {Optional findOne = memberService.findOne(insertedUserId);Member member = findOne.orElseThrow(() -> new UsernameNotFoundException("없는 회원입니다 ㅠ"));return User.builder().username(member.getUserid()).password(member.getPw()).roles(member.getRoles()).build();}}loadUserByUsername 오버라이드
loadUserByUsername를 오버라이드해서 구현해주면 됩니다. 파라미터인 insertedId 부분에는 기존에 설정해두었던 usernameParameter("userid")에 해당하는 정보가 들어오게 됩니다. 비밀번호가 동일한지 체크는 스프링부트에서 알아서 진행하게 되므로 아이디만 가지고 DB에서 유저 정보를 가져오시면 됩니다.
DB에서 유저 정보를 가져오고, 그걸 UserDetails로 담아서 리턴해줘야 합니다.. User 클래스 (org.springframework.security.core.userdetails.User)의 빌더를 사용해서 username에 아이디, password에 비밀번호, roles에 권한(역할)을 넣어주면 UserDetails가 리턴 됩니다. roles는 현재 예시에서는 한 유저당 하나의 역할만 사용할거라서 그냥 String 형태로 넣어줬는데, 여러 권한이 있다면 String[] 배열 형태로 넣어주면 됩니다.
현재까지의 패키지 구조는 다음과 같습니다.
이제 실행해보면 콘솔창에 임시 비밀번호가 안떠요!
실제 DB에서 데이터를 가져와서 스프링부트한테 넣어주는 방식을 추가했으니 이제 스프링부트는 임시 비밀번호를 줄 필요가 없어졌습니다. 그래서 실행해보면 임시비밀번호가 안뜨는걸 볼 수 있어요.
5.2 DB의 데이터 기준으로 로그인하려니 에러나요.
현재 회원가입이 가능한 상태입니다. 따라서 회원가입 후 로그인을 시도하거나, resources 폴더의 data.sql 에 기본 데이터를 넣어놨으므로 그걸 사용해 로그인을 해보면 (user/1234) 에러가 납니다.
비밀번호 암호화를 아직 지정하지 않아서 그렇습니다. 설명은 '6'에서 진행할거니 우선은 DB에서 가져온 데이터로 로그인만 되는걸 확인해봅시다.
config 패키지에 다음과 같이 클래스를 추가해줍니다.
public class SimplePasswordEncoder implements PasswordEncoder {@Overridepublic String encode(CharSequence rawPassword) {return rawPassword.toString();}@Overridepublic boolean matches(CharSequence rawPassword, String encodedPassword) {return encodedPassword.equals(encode(rawPassword));}}
그리고 SpringSecurityConfig 에 다음과 같이 추가해줍시다.
@BeanPasswordEncoder passwordEncoder() {return new SimplePasswordEncoder();}그럼 이제 회원가입한 후 해당 계정으로 로그인도 가능하고, 미리 지정해둔 계정으로도 로그인이 가능해집니다.
아래처럼 화면이 변경되면 로그인이 성공한겁니다. 아직 접속 아이디, 역할이나 이동 권한 확인은 동작하지 않습니다. 차례차례 해결해보죠.
6. DB에 비밀번호가 그대로 보이는데 이거 괜찮나요?!
당연히 안괜찮습니다! 현재 DB에 데이터가 암호화되지 않고 비밀번호 문자열 그대로 들어가 있습니다. 회원가입 시 비밀번호가 암호화되어 DB에 들어가야하고, 그렇다면 로그인 시에도 암호화된 비밀번호를 기준으로 비교가 가능해야 합니다.
6.1 스프링 부트가 어떻게 비밀번호를 확인하는지 알아보기.
5.2에서 봤던 코드를 다시 봐보겠습니다. PasswordEncoder라는걸 빈으로 만들어 스프링 컨테이너에 등록시키고 있는걸 볼 수 있습니다. 이게 비밀번호 인코더를 선택하는 방식입니다. 아예 SimplePasswordEncoder()에 @Component를 넣어서 컨테이너에 등록할 수도 있지만, 이렇게 설정하셔야 차후 인코더를 변경하더라도 다른 코드에 변경의 여파가 미치지 않습니다.
@BeanPasswordEncoder passwordEncoder() {return new SimplePasswordEncoder();}
마찬가지로 5.2에서 만들어봤던 PasswordEncoder를 살펴보죠. 뭐 암호화를 아예 진행하지 않은 클래스이지만, 어떤식으로 스프링부트가 알아서 비밀번호를 확인하는지 볼려고 짜봤습니다.
public class SimplePasswordEncoder implements PasswordEncoder {@Overridepublic String encode(CharSequence rawPassword) {return rawPassword.toString();}@Overridepublic boolean matches(CharSequence rawPassword, String encodedPassword) {return encodedPassword.equals(encode(rawPassword));}}
PasswordEncoder에는 encode와 matches가 있습니다.
- encode : 해당 암호화 방식으로 암호화한 문자열을 리턴해줍니다. 회원가입 시 DB에 넣기전에 이걸 호출해 암호화하면 되겠죠.
- matches : rawPassword가 로그인 시 사용자가 입력한 비밀번호입니다. 'usernameParameter("pw")' 로 설정해둔 부분의 값이 rawPassword로 들어와집니다. encodedPassword는 DB에서 조회한 이미 암호화되어있는 비밀번호 입니다. 5.1에서 설정한 loadUserByUsername 에서 UserDetails에 넣어준 password() 부분이 여기로 들어옵니다.
따라서 rawPassword를 단방향 암호화로 암호화해준 뒤 encodedPassword와 동일한지 확인해보면, 원래 암호 자체가 어떤 문자열이었는지는 누구도 알 수 없지만 아무튼 DB에 있는거랑 로그인 시 입력한 문자열이 동일한지는 알 수 있게 됩니다. 물론 Bcrypt 등의 암호화는 이런식으로 단순 매칭하는게 아니긴 하지만 그 부분은 별도로 찾아보시면 좋을 것 같습니다. 어차피 암호화 알고리즘은 저희가 구현해서 쓸일은 잘 없고, 이미 구현되있는걸 쓸꺼니까요!
6.2 회원가입 할때도 암호화해서 DB에 넣어주자.
아직 암호화 알고리즘을 변경하진 않았지만 우선 회원가입 시에도 암호화를 적용해주자. 보통 서비스단에서 암호화를 해주는걸로 알고 있는데, 제 경우엔 암호화도 비지니스 로직에 해당한다고 생각됩니다. 따라서 도메인 계층의 Member에서 암호화를 해줄껍니다. 이건 각자의 판단에 맞춰 짜시면 될 것 같아요.
도메인 계층에 PasswordEncoder를 넣고싶진 않으니 정적 팩토리를 만들고 인코더는 주입받도록 하겠습니다. Member.java의 createUser를 아래와 같이 변경해줍니다.
@Entitypublic class Member {... public static Member createUser(String userId, String pw, PasswordEncoder passwordEncoder) {return new Member(null, userId, passwordEncoder.encode(pw), "USER");}}
RegisterMemberService는 다음과 같이 수정해줍니다. SimplePasswordEncoder를 바로 받은게 아니고, PasswordEncoder를 주입받았습니다. 따라서 차후 PasswordEncoder 빈이 변경되더라도 코드가 변경되지 않을 것이란걸 예상해볼 수 있습니다.
@Servicepublic class RegisterMemberService {private final PasswordEncoder passwordEncoder;private final MemberRepository repository;@Autowiredpublic RegisterMemberService(PasswordEncoder passwordEncoder, MemberRepository repository) {this.passwordEncoder = passwordEncoder;this.repository = repository;}public Long join(String userid, String pw) {Member member = Member.createUser(userid, pw, passwordEncoder);validateDuplicateMember(member);repository.save(member);return member.getId();}...}
이제 컨트롤러에서 회원가입을 받으면 정해둔 PasswordEncoder의 encode 함수가 불려 비밀번호를 암호화한 후 DB에 넣도록 세팅이 되었습니다. 회원가입 API는 아래처럼 생겼습니다. 이건 변경될 부분이 없습니다. 회원가입 시 프론트쪽에 가입된 정보를 알려줄 이유는없습니다. 그냥 성공 여부만 알려주면 됩니다. 이후 프론트에서 유저 정보를 들고있는 경우는 없고 모든건 백엔드에서 통제할겁니다.
@RestController@RequestMapping("/auth")public class AuthorizationController {private final RegisterMemberService registerMemberService;public AuthorizationController(RegisterMemberService registerMemberService) {this.registerMemberService = registerMemberService;}@PostMapping("/join")public ResponseEntity join(@RequestBody MemberJoinDto dto) {try {registerMemberService.join(dto.getUserid(), dto.getPw());return ResponseEntity.ok("join success");} catch (Exception e) {return ResponseEntity.badRequest().body(e.getMessage());}}}이제 실행해서 다시 회원가입을 하면 아직은 뭐가 바꼈는지 알수 없을겁니다! 이제 암호화 알고리즘을 선택해 정말 암호화되서 들어가는지 확인해봅시다.
6.3 BCrypt 암호화 알고리즘을 적용하자!
암호화 알고리즘을 직접 짜야되는 경우는 거의 없습니다. 이미 스프링부트에서 잘 짜놓은 좋은 암호화 방식들이 있습니다. 별도로 사용하고 싶은 암호화 방식이 있다면 검색해서 적용시키면 됩니다. 이 글에서 사용할 암호화 알고리즘은 BCrypt 입니다. 강력한 알고리즘으로 일반적으로 특별히 뭘 써야만 하는 상황이 아니라면 그냥 이거 쓰시면 됩니다.
이제 SimplePasswordEncoder는 필요없으니 삭제해버리고, SpringSecurityConfig에서 PasswordEncoder 빈만 다음처럼 변경해주면 됩니다!
@Configurationpublic class SpringSecurityConfig {@BeanPasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}...}이제 회원가입을 하고 DB를 봐보죠!
비밀번호가 아래처럼 이상한 문자들로 변경되어 들어갔습니다.심지어 둘 다 비밀번호에 1234를 넣었는데 다르게 들어갔네요!
$2a$10$x1bbkrOQcBDpNtAGBSjmouivSgseW1SGnu7KUsfKvY1kSB1IAnide
$2a$10$AH/P7bEHWAGFmxdpsHM/3OWIeP81ydy0KNJg2CA3AEM0cayXpJ91K
원래 단방향 알고리즘이라도 암호화된 후 값이 동일하다면 사실 하나만 어떻게든 암호를 알아내면 암호화 후의 문자열이 동일한 모든 비밀번호도 알아낼 수 있습니다. Bcrypt 사용시엔 위처럼 동일한 문자라도 서로다른 문자가 들어가게 되서 이런 부분을 막을 수 있습니다.
data.sql에 기본 데이터를 넣어두실려면 이제 암호화된 문자열을 넣어두셔야 합니다.
이제 암호화된 비밀번호로 로그인이 잘 됩니다!
회원가입시엔 암호화되어 DB에 들어가고, 로그인 시에도 알아서 암호화되서 비교되는 시스템이 잘 동작됩니다. 이제 비밀번호 데이터가 탈취되더라도 한시름 놓겠네요. 무차별 대입(brute force)만 잘 대응하면 될 것 같은데, BCrypt는 이 부분에도 강점이 있어서 좋습니다.
6.4 원하는 암호화 알고리즘 적용 예시
혹시 프로젝트 요구사항 자체에 비밀번호 암호화를 강제하는 경우도 있을 수 있습니다. 시큐리티가 기본적으로 제공해주는 인코더면 좋겠지만, 그렇지 않을수도 있죠. 이 경우엔 저희가 6.1에서 봤던 Simple 인코더처럼 직접 구현해줘야 합니다.
예를들어 SHA512를 직접 짜서 적용해본다면 다음처럼 짜볼 수 있겠습니다. (이건 해보지 않으셔도 됩니다.)
public class SHA512PasswordEncoder implements PasswordEncoder {@Overridepublic String encode(CharSequence rawPassword) {if (rawPassword == null) {throw new IllegalArgumentException("rawPassword cannot be null");}return this.getSHA512Pw(rawPassword);}@Overridepublic boolean matches(CharSequence rawPassword, String encodedPassword) {if (rawPassword == null) {throw new IllegalArgumentException("rawPassword cannot be null");}if (encodedPassword == null || encodedPassword.length() == 0) {return false;}String encodedRawPw = this.getSHA512Pw(rawPassword);return encodedRawPw.equals(encodedPassword);}private String getSHA512Pw(CharSequence rawPassword) {MessageDigest md = null;try {md = MessageDigest.getInstance("SHA-512");md.update(rawPassword.toString().getBytes());} catch (Exception e) {e.printStackTrace();}byte[] msgb = md.digest();StringBuilder sb = new StringBuilder();for (int i = 0; i < msgb.length; i++) {String tmp = Integer.toHexString(msgb[i] & 0xFF);while (tmp.length() < 2)tmp = "0" + tmp;sb.append(tmp.substring(tmp.length() - 2));}return sb.toString();}}
마찬가지로 SpringSecurityConfig 쪽에 빈만 갈아끼워주면 됩니다.
@BeanPasswordEncoder passwordEncoder() {return new SHA512PasswordEncoder();}
7. 근데 웹페이지에 유저 정보 있어야 API 사용한게 누군지 알지 않아요?
네이버에 로그인하면 이후 로그인창 부분에 유저 닉네임 등의 정보가 떠있습니다. 이건 웹페이지에서 유저 정보를 들고있어서 표시 가능한게 아닌가요?!
7.1 백엔드에서 유저 정보 알아내기!
우선 코드부터 수정해보죠! ViewController의 dashboardPage를 아래처럼 수정해줍니다. (User 말고 UserDetails 로 받아도 됩니다.)
@Controller@RequestMapping("/view")public class ViewController {...@GetMapping("/dashboard")public String dashboardPage(@AuthenticationPrincipal User user, Model model) {model.addAttribute("loginId", user.getUsername());model.addAttribute("loginRoles", user.getAuthorities());return "dashboard";}}
그리고 dashboard.jsp 에서도 받을 수 있도록 다음처럼 수정해줍니다.
...nahwasa.com접속 아이디${loginId}
역할${loginRoles}
역할에 따른 페이지 이동 권한 확인...
그리고 실행해보면 아래처럼 접속 아이디와 역할을 확인할 수 있습니다! 프론트에선 해당 정보를 들고있지 않았는데 로그인했더니 백엔드로부터 유저 정보를 얻어올 수 있게 된거죠. 사용자가 프론트 화면에서 아무리 접속 아이디나 역할을 수정하려고 해봤자 F5 누르거나 다른 페이지로 이동하면 다시 백엔드에서 정보를 받아오니 무용지물입니다. 아 물론 저렇게 받은 부분에 password는 지워져있습니다.
@AuthenticationPrincipal 로 유저 정보를 백엔드에서 획득 가능합니다.
이제 프론트에서 유저 정보를 들고 있을 필요가 없다는걸 알 수 있습니다. 그렇다면 이 백엔드 서버에 여러명이 접근하면 혹시 획득하는 유저 정보가 마지막에 로그인한 유저로 전부 바뀔까요? 당연히 아닙니다. 이 부분은 스프링 부트에 내장된 톰캣과 브라우져가 쿠키를 통해 알아서 처리하는 부분입니다. 자세히 알고 싶다면 세션 혹은 쿠키로 검색해서 찾아보시면 됩니다.
아직 '관리자 설정 페이지'와 '유저 설정 페이지'는 둘 다 들어갈 수 있습니다!
아직 권한에 따른 처리를 안했으므로 접근이 제한되진 않습니다. 다음 장에서 이 부분을 다룰겁니다.
현재까지의 패키지 구조는 아래와 같습니다.
8. 관리자 전용 페이지는 관리자만 접근 가능해야 하는거 아니에요?
사실 그동안 따로 말하진 않았는데, DB에 이미 역할을 정해두었습니다. 역할이 있다면 이에 따라 뭔가 다른 동작을 서버에 적용시킬 수 있어야합니다. 이번에는 대시보드에 있는 '관리자 설정 페이지'와 '유저 설정 페이지'에 권한(역할)에 따른 접근 제어를 해보겠습니다.
8.1 url path 를 통한 접근 제어
만약 url path를 역할에 따라 구분을 잘 지어뒀다면 url path를 통해 접근 제어를 하면 간단하게 제어할 수 있을 것 같습니다. 우선 해당 버튼을 클릭할 시 이동하는 주소를 봐보겠습니다. 각각 /view/setting/admin과 /view/setting/user 입니다.
@Controller@RequestMapping("/view")public class ViewController {...@GetMapping("/setting/admin")public String adminSettingPage() {return "admin_setting";}@GetMapping("/setting/user")public String userSettingPage() {return "user_setting";}}
SpringSecurityConfig에 path에 따른 제어를 설정해보겠습니다. requestMathcers로 path를 지정하고, hasRole을 통해 해당 권한을 가져야 접근 가능함을 설정합니다.
@Configuration@EnableWebSecurity@EnableMethodSecuritypublic class SpringSecurityConfig {...@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.csrf().disable().cors().disable().authorizeHttpRequests(request -> request.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll().requestMatchers("/status", "/images/**", "/view/join", "/auth/join").permitAll().requestMatchers("/view/setting/admin").hasRole("ADMIN").requestMatchers("/view/setting/user").hasRole("USER").anyRequest().authenticated()).formLogin(login -> login.loginPage("/view/login").loginProcessingUrl("/login-process").usernameParameter("userid").passwordParameter("pw").defaultSuccessUrl("/view/dashboard", true).permitAll()).logout(withDefaults());return http.build();}}이제 실행해 보겠습니다.
현재 회원가입 시 ADMIN 권한은 회원가입이 불가능합니다. 따라서 data.sql에 작성해서 사용해야 합니다. 제가 설정해둔건 아이디 : nahwasa / 비밀번호 : 1234 입니다. 이걸로 로그인이 안된다면, 위에 BCrypt 설정하는 부분에서 data.sql을 수정하지 않으셨기 때문입니다. 이 경우 data.sql에 가서 nahwasa쪽 비밀번호로 이하를 작성해주시면 됩니다.
$2a$10$x1bbkrOQcBDpNtAGBSjmouivSgseW1SGnu7KUsfKvY1kSB1IAnide
우선 ADMIN 권한으로 로그인 해봅시다. (nahwasa/1234)
관리자 설정 페이지는 접근이 잘 되고, 유저 설정 페이지는 403이 뜹니다.
반대로 회원가입을 하거나, user/1234로 접속해보면 관리자 페이지는 403, 유저 페이지는 정상적으로 접근되는 것을 볼 수 있습니다.
8.2 컨트롤러 혹은 API 단위 접근 제어
유저 역할에 따라 API의 path가 구분된다면 8.1처럼 세팅하면 편하고 좋겠지만, 제 경우엔 컨트롤러나 API 별로 지정하는걸 더 선호하는 편입니다.
8.1과 8.2를 함께 사용해도 되지만, 우선은 SpringSecurityConfig쪽에 설정해둔 hasRole 부분은 삭제하겠습니다.
....authorizeHttpRequests(request -> request.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll().requestMatchers("/status", "/images/**", "/view/join", "/auth/join").permitAll().anyRequest().authenticated())...
그리고 ViewController쪽에 PreAuthorize를 추가해줍니다.
@Controller@RequestMapping("/view")public class ViewController {...@GetMapping("/setting/admin")@PreAuthorize("hasAnyRole('ADMIN')")public String adminSettingPage() {return "admin_setting";}@GetMapping("/setting/user")@PreAuthorize("hasAnyRole('USER')")public String userSettingPage() {return "user_setting";}}그럼 이제 8.1에서 세팅한것과 동일하게 설정이 된 겁니다! 직접 실행해보시면 동일하게 권한에 따른 제어가 된걸 볼 수 있습니다.
PreAuthorize를 컨트롤러 클래스에 걸게되면 해당 컨트롤러에 해당하는 모든 API에 권한이 걸립니다.@Controller@RequestMapping("/view")@PreAuthorize("hasAnyRole('ADMIN')")public class ViewController {...}
개인적으로 반복되는 문자열 값이 매번 들어가는게 보기 싫습니다.
매번 API 마다 아래처럼 문자열로 된 값을 넣어줘야하는데, 상당히 꼴뵈기 싫습니다.
@PreAuthorize("hasAnyRole('ADMIN')")
제 경우엔 config 패키지쪽에 커스텀 어노테이션으로 만들어주었습니다.
@Target({ ElementType.METHOD, ElementType.TYPE })@Retention(RetentionPolicy.RUNTIME)@PreAuthorize("hasAnyRole('ADMIN')")public @interface AdminAuthorize {}---@Target({ ElementType.METHOD, ElementType.TYPE })@Retention(RetentionPolicy.RUNTIME)@PreAuthorize("hasAnyRole('USER')")public @interface UserAuthorize {}
그럼 이제 ViewController에 아래처럼 적용해주면 됩니다.
@Controller@RequestMapping("/view")public class ViewController {...@GetMapping("/setting/admin")@AdminAuthorizepublic String adminSettingPage() {return "admin_setting";}@GetMapping("/setting/user")@UserAuthorizepublic String userSettingPage() {return "user_setting";}}
9. 이제 시큐리티 기본 세팅이 끝났습니다!9.1 최종 코드
github (글이 괜찮으셨다면 깃헙 ⭐!)
9.2 마무리!
사실 엄청 기본 세팅만 한건데도 TMI가 많다보니 글이 길어졌네요. 저도 아직 시큐리티를 정확히 알진 못해서 이렇게 장문의 글을 쓰는게 맞나 싶네요. 어쨌든 기본적으로 사용하기엔 문제가 없어보이는 프로젝트가 완성되었습니다. 이제 더 필요한 부분들은 직접 찾아 살을 더해나가시면 될 것 같습니다. OAuth나 JWT 같은것도 공부하셔야할테구요.
References
https://docs.spring.io/spring-security/reference/5.8.0/migration/servlet/authorization.html#_permit_forward_when_using_spring_mvc
Authorization Migrations :: Spring Security
The following steps relate to changes around how authorization is performed.
docs.spring.io
https://docs.spring.io/spring-security/site/docs/5.7.0-M2/api/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurerAdapter.html#configure%25org.springframework.security.config.annotation.web.builders.WebSecurity%29
WebSecurityConfigurerAdapter (spring-security-docs 5.7.0-M2 API)
Provides a convenient base class for creating a WebSecurityConfigurer instance. The implementation allows customization by overriding methods. Will automatically apply the result of looking up AbstractHttpConfigurer from SpringFactoriesLoader to allow deve
docs.spring.io
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
Spring | Home
Cloud Your code, any cloud—we’ve got you covered. Connect and scale your services, whatever your platform.
spring.io
https://docs.spring.io/spring-security/reference/5.8/migration/index.html
Preparing for 6.0 :: Spring Security
The first step is to ensure you are the latest patch release of Spring Boot 2.7. Next, you should ensure you are on the latest patch release of Spring Security 5.8. If you are using Spring Boot, you will need to override the Spring Boot version from Spring
docs.spring.io
https://github.com/spring-projects/spring-security/issues/12479
Configuration rules that worked in Spring Security 5.7.6 don't work in 6.0.1 · Issue #12479 · spring-projects/spring-security
Describe the bug Configuration rules that worked in Spring Security 5.7.6 don't work in 6.0.1. After migrating the security configuration to Spring Security 6.0.1, there is an endless redirect ...
github.com